10.3 精通自定义 View 之 Android 画布——SurfaceView

返回自定义 View 目录

10.3.1 概述

Android 屏幕刷新的时间间隔是 16ms,如果 View 能够在 16ms 内完成所需执行的绘图操作,那么在视觉上,界面就是流畅的;否则就会出现卡顿。很多时候,在自定义 View 的日志中,经常会看到如下警告:

1
Skipped 60 frames! The application may be doing too much work on its main thread

之所以会出现这些警告,大部分是因为我们在绘制过程中不单单执行了绘图操作,也夹杂了很多逻辑处理,导致在指定的 16ms 内并没有完成绘制,出现界面卡顿和警告。为了解决这个问题,Android 引入了 SurfaceView。它在两个方面改进了 View 的绘图操作:

  • 使用双缓冲技术。
  • 自带画布,支持在子线程中更新画布内容。

所谓双缓冲技术,简单来讲,就是多加一块缓冲画布,当需要执行绘图操作时,现在缓冲画布上绘制,绘制好后直接将缓冲画布上的内容更新到主画布上。这样,在屏幕更新时,只需把换缓冲画布上的内容照样画过来就可以了,就不会存在逻辑处理时间的问题,也就解决了超时绘制的问题。具体详见 10.3.3。

虽然 SurfaceView 在处理耗时操作时很有用,但正是因为在新的线程中更新画面,所以不会阻塞主线程。但这也带来了另一个问题,就是事件同步。比如,你触摸了屏幕,SurfaceView 就会调用线程来处理,当线程过多时,一般就需要一个线程队列来保存触摸事件,这会稍稍复杂一点,因为涉及线程同步。

总之,View 和 SurfaceView 都有各自的应用场景:

  • 当界面需要被动更新时,用 View 较好。比如,与手势交互的场景,因为画面的更新是依赖 onTouch 来完成的,所以可以直接使用 invalidate() 函数。在这种情况下,这一次 Touch 和下一次 Touch 间隔的时间比较长,不会产生影响。
  • 当界面需要主动更新,用 SurfaceView 较好。比如一个人在一直跑动,这就需要一个单独的线程不停地重绘人的状态,避免阻塞主线程。显然 View 不合适,需要 SurfaceView 来控制。
  • 当界面绘制需要频繁刷新,或者刷新是数据处理量比较大时,就应该用 SurfaceView 来实现,比如视频播放及 Camera。

10.3.2 SurfaceView 的基本用法

1. 实现 View 功能

SurfaceView 派生自 View,所以 SurfaceView 能使用 View 中的所有方法,但要注意,View 中的所有方法都是在主线程中执行。下面使用 SurfaceView 来实现捕捉用户手势轨迹的自定义控件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class TestView extends SurfaceView {
private Path mPath;
private Paint mPaint;
public TestView(Context context) {
super(context);
init();
}
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public TestView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}
private void init() {
// setWillNotDraw(false);
mPaint = new Paint();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mPaint.setColor(Color.RED);
mPath = new Path();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
if (event.getAction() == MotionEvent.ACTION_DOWN) {
mPath.moveTo(x, y);
Log.d("xian", "ACTION_DOWN");
return true;
} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
mPath.lineTo(x, y);
}
postInvalidate();
Log.d("xian", "invalidate");
return super.onTouchEvent(event);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mPath, mPaint);
Log.d("xian", "onDraw");
}
}

然而,效果却是不显示手势轨迹,而一直显示黑屏。查看日志,如下图所示。

从日志中可以看出,上述代码只调用了 postInavidate() 函数,而没有调用 onDraw() 函数。这是为什么呢?当你把 init() 函数中注释掉的一行代码打开以后,就会发现可以看到手势轨迹了。

setWillNotDraw(boolean willNotDraw) 这个函数存在于 View 类中,它主要用在 View 派生子类的初始化中,如果参数 willNotDraw 取 true,则表示当前控件没有绘制内容,当屏幕重绘的时候,这个控件不需要绘制,所以在重绘的时候也就不会调用这个类的 onDraw() 函数。相反,如果参数 willNotDraw 取 false,则表示当前控件在每次重绘时,都需要绘制该控件。可见,setWillNotDraw 其实是一种优化策略,它让控件显示地告诉系统,在重绘屏幕时,哪个控件需要重绘,哪个控件不需要重绘,这样就可以大大提高重绘效率。

一般而言,想 LinearLayout、RelativeLayout 等布局控件,它们的主要功能是布局其中的控件,它们本身是没有东西需要绘制的,所以它们在构造的时候都会显示设置 setWillNotDraw(true)

总结:

  • 原本能够通过派生自 View 实现的控件,依然可以通过 SurfaceView 来实现,因为 SurfaceView 派生自 View。
  • 当 SurfaceView 需要使用 View 的 onDraw() 函数来重绘控件时,需要在初始化的时候调用 setWillNotDraw(false),否则 onDraw() 函数不会被调用。
  • View 中的所有方法都是在主线程中执行的,所以并不建议使用 SurfaceView 重写 View 的 onDraw() 函数来实现自定义控件,而要使用 SurfaceView 特有的双缓冲机制绘图。

2. 使用缓冲 Canvas 绘图

通过以下方式来获取 SurfaceView 自带的画布。

1
2
3
4
SurfaceHolder surfaceHolder = getHolder();
Canvas canvas = surfaceHolder.lockCanvas();
// TODO 绘图操作
surfaceHolder.unlockCanvasAndPost(canvas);

前面说过线程同步问题,所以需要给获取的缓冲画布进行加锁,防止被其他线程更改;当绘图操作完成以后,将缓冲画布释放,并将所画内容更新到主线程的画布上,显示在屏幕上。使用缓冲画布来改造上面的示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class TestView extends SurfaceView {
private Path mPath;
private Paint mPaint;
public TestView(Context context) {
super(context);
init();
}
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public TestView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}
private void init() {
mPaint = new Paint();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mPaint.setColor(Color.RED);
mPath = new Path();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
if (event.getAction() == MotionEvent.ACTION_DOWN) {
mPath.moveTo(x, y);
return true;
} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
mPath.lineTo(x, y);
}
drawCanvas();
return super.onTouchEvent(event);
}
private void drawCanvas() {
new Thread(new Runnable() {
@Override
public void run() {
SurfaceHolder surfaceHolder = getHolder();
Canvas canvas = surfaceHolder.lockCanvas();
if (canvas != null) {
canvas.drawPath(mPath, mPaint);
}
surfaceHolder.unlockCanvasAndPost(canvas);
}
}).start();
}
}

注意,onTouchEvent() 函数是在主线程执行的,所以我们需要开启子线程更新画布。效果图如下。

3. 监听 Surface 生命周期

与 SurfaceView 相关的有三个概念:Surface、SurfaceView、SurfaceHolder。这三个概念是典型的 MVC 模式 (Model-View-Controller)。Surface 是 Model,保存着缓冲画布和绘图内容相关的各种信息;SurfaceView 是 View,负责将 Surface 中存储的数据展示在 View 上;SurfaceHolder 是 Controller,使用它才能操作 Surface 中的数据。

生命周期监听函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SurfaceHolder surfaceHolder = getHolder();
surfaceHolder.addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
// 当 Surface 对象被创建后,该函数就会被立即调用
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
// 当 Surface 发生任何结构性的变化时(格式或者大小),该函数就会被立即调用
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
// 当 Surface 对象将要被销毁时,该函数就会被立即调用
}
});

示例:动态背景效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
public class TestView extends SurfaceView {
private SurfaceHolder mHolder;
private boolean flag = false; // 线程标示
private Bitmap mBgBitmap; // 背景图
private float mSurfaceWidth, mSurfaceHeight; // 屏幕宽高
private int mBitPosX; // 开始绘制的图片的 x 坐标
private Canvas mCanvas;
private Thread mThread;
// 背景移动状态
private enum State {
LEFT, RIGHT
}
// 默认向左
private State state = State.LEFT;
private final int BITMAP_STEP = 5; // 背景画布移动步伐
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
mHolder = getHolder();
mHolder.addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
flag = true;
startAnimation();
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
flag = false;
}
});
}
private void startAnimation() {
mSurfaceWidth = getWidth();
mSurfaceHeight = getHeight();
try {
// 按比例缩放图片,是高度充满屏幕
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.qingmingshanghetu);
float width = mSurfaceHeight / bitmap.getHeight() * bitmap.getWidth();
mBgBitmap = Bitmap.createScaledBitmap(bitmap, (int)width, (int)mSurfaceHeight, true);
} catch (OutOfMemoryError error) {
error.printStackTrace();
}
if (mBgBitmap == null) return;
// 开始绘图
mThread = new Thread(new Runnable() {
@Override
public void run() {
while (flag) {
mCanvas = mHolder.lockCanvas();
drawView();
mHolder.unlockCanvasAndPost(mCanvas);
try {
Thread.sleep(16);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
mThread.start();
}
protected void drawView() {
if (mCanvas == null) return;
// 清空屏幕
mCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
// 从 mBitPosX 开始绘制屏幕背景
mCanvas.drawBitmap(mBgBitmap, mBitPosX, 0, null);
// 图片滚动效果
switch (state) {
case LEFT:
mBitPosX -= BITMAP_STEP; // 画布左移
break;
case RIGHT:
mBitPosX += BITMAP_STEP; // 画布右移
break;
}
if (mBitPosX <= -mSurfaceWidth / 2) {
state = State.RIGHT;
}
if (mBitPosX >= 0) {
state = State.LEFT;
}
}
}

10.3.3 SurfaceView 双缓冲技术

1. 概述

双缓冲技术需要两个图形缓冲区:前端缓冲区和后端缓冲区。前端缓冲区对应当前屏幕正在显示的内容,而后端缓冲区是接下来要渲染的图形缓冲区。通过 surfaceHolder.lockCanvas() 函数获得的缓冲区是后端缓冲区。当绘图完成以后,调用 surfaceHolder.unlockCanvasAndPost(mCanvas) 函数将后端缓冲区与前端缓冲区交换,后端缓冲区变成前端缓冲区,将内容显示在屏幕上;而原来的前端缓冲区则变成后端缓冲区,等待下一次的 surfaceHolder.lockCanvas() 函数调用返回给用户使用,如此反复。

正是由于两块画布交替用来绘图,在绘图完成以后相互交换位置,而且在绘图完成以后直接更新到屏幕上,所以才使得绘图效率大大提高。而这样做却造成了一个问题:两块画布上的内容肯定会存在不一致的情况,尤其是在多线程的情况下。比如,我们利用一个线程操作 A、B 两款画布,目前 A 画布是屏幕画布,所以,当线程要绘图时,获得的缓冲画布是 B。在更新以后,B 画布更新到屏幕上,A 画布与 B 画布交换位置。而这时,如果线程再次申请画布,则将获取到 A 画布。如果 A 画布与 B 画布上的内容不一样,那么,在 A 画布上继续作画肯定会与预想的不一样。

示例:每获取一次画布写一个数字,循环 10 次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class TestView extends SurfaceView {
private Paint mPaint;
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setTextSize(30);
getHolder().addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
drawText(holder);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {}
});
}
private void drawText(SurfaceHolder holder) {
for (int i = 0; i < 10; i++) {
Canvas canvas = holder.lockCanvas();
if (canvas != null) {
canvas.drawText(i + "", i * 30, 50, mPaint);
}
holder.unlockCanvasAndPost(canvas);
}
}
}

效果如下图所示:

按照我们的逻辑,如果有两块缓冲画布,那么结果应该是 1 3 5 7 9。因为最后一个更新的数字必然是 9,而往前推,每次间隔使用画布,跟 9 在同一块画布上的必然是 1 3 5 7,其他数字都在另一块画布上。但结果为什么是 0 3 6 9 呢?这是因为这里有三块缓冲画布。

如果我们在绘图时使用单独的线程,而且每次绘图完成以后,让线程休眠一段时间,就可以明显地看到每次所绘制的数字了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void drawText(final SurfaceHolder holder) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
Canvas canvas = holder.lockCanvas();
if (canvas != null) {
canvas.drawText(i + "", i * 100, 150, mPaint);
}
holder.unlockCanvasAndPost(canvas);
try {
Thread.sleep(600);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}

效果如下图所示。

从效果图中可以看出每次获取到的画布上所绘制的内容,很明显,0、1、2 这三个数字是分别在三块空白的画布上绘制的,之后的每个数字都是依次在这三块画布上绘制的。

有关 Surface 中缓冲画布的数量,Google 给出的解释 是:Surface 中缓冲画布的数量是根据需求动态分配的。如果用户获取画布的频率较慢,那么将会分配两块缓冲画布;否则,将分配 3 的倍数缓冲画布,具体分配多少块,视情况而定。

2. 双缓冲技术局部更新原理

SurfaceView 支持局部更新,可以通过 Canvas lockCanvas(Rect dirty) 函数指定获取画布的区域和大小。画布以外的地方会将现在屏幕上的内容复制过来,以保持与屏幕一致;而画布以内的区域则保持原画布内容。

  • lockCanvas():用于获取整屏画布,屏幕内容不会被更新到画布上,画布保持原画布内容。
  • lockCanvas(Rect dirty):用于获取指定区域的画布,画布以外的区域会保持与屏幕内容一致,画布以内的区域依然保持原画布内容。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
public class TestView extends SurfaceView {
private Paint mPaint;
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
mPaint = new Paint();
mPaint.setColor(Color.argb(0x1F, 0xFF, 0xFF, 0xFF));
mPaint.setTextSize(60);
getHolder().addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
drawText(holder);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {}
});
}
private void drawText(final SurfaceHolder holder) {
new Thread(new Runnable() {
@Override
public void run() {
// 先进行清屏操作
while (true) {
Rect dirtyRect = new Rect(0, 0, 1, 1);
Canvas canvas = holder.lockCanvas(dirtyRect);
Rect canvasRect = canvas.getClipBounds();
if (getWidth() == canvasRect.width() && getHeight() == canvasRect.height()) {
canvas.drawColor(Color.BLACK);
holder.unlockCanvasAndPost(canvas);
} else {
holder.unlockCanvasAndPost(canvas);
break;
}
}
// 画图
for (int i = 0; i < 10; i++) {
// 画大方
if (i == 0) {
Canvas canvas = holder.lockCanvas(new Rect(10, 10, 600, 600));
canvas.drawColor(Color.RED);
holder.unlockCanvasAndPost(canvas);
}
// 画中方
if (i == 1) {
Canvas canvas = holder.lockCanvas(new Rect(30, 30, 570, 570));
canvas.drawColor(Color.GREEN);
holder.unlockCanvasAndPost(canvas);
}
// 画小方
if (i == 2) {
Canvas canvas = holder.lockCanvas(new Rect(60, 60, 540, 540));
canvas.drawColor(Color.BLUE);
holder.unlockCanvasAndPost(canvas);
}
// 画圆形
if (i == 3) {
Canvas canvas = holder.lockCanvas(new Rect(200, 200, 400, 400));
mPaint.setColor(Color.argb(0x3F, 0xFF, 0xFF, 0xFF));
canvas.drawCircle(300, 300, 100, mPaint);
holder.unlockCanvasAndPost(canvas);
}
// 写数字
if (i == 4) {
Canvas canvas = holder.lockCanvas(new Rect(250, 250, 350, 350));
mPaint.setColor(Color.RED);
canvas.drawText(i + "", 300, 300, mPaint);
holder.unlockCanvasAndPost(canvas);
}
try {
Thread.sleep(800);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
}

分析过程略,得出以下几个结论:

  • 缓冲画布的存取遵循 LRU(先进先出)策略。
  • 画布以内的区域仍在原缓冲画布上叠加作画,画布以外的区域是从屏幕上直接复制过来的。
  • 为了防止画布以内的缓冲画布本身的图像与所画内容产生冲突,在对画布以内的区域作画时,建议先清空画布。

3. 局部更新为何要先清屏

因为这里有三块缓冲画布,有一块画布初始化地被显示在屏幕上,已经被默认填充为黑色,而另外两块画布都还没有被画过。虽然我们指定了获取画布的区域范围,但是系统认为,整块画布都是脏区域,都应该被画上,所以会返回屏幕大小的画布。只有将每块画布都画过以后,才会按照我们指定的区域来返回画布大小。

4. 双缓冲技术解决方案

方案一:保存所有要绘制的内容,全屏重绘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class TestView extends SurfaceView {
private Paint mPaint;
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setTextSize(100);
getHolder().addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
drawText(holder);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {}
});
}
private List<Integer> mInts = new ArrayList<>();
private void drawText(final SurfaceHolder holder) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
Canvas canvas = holder.lockCanvas();
mInts.add(i);
if (canvas != null) {
for (int num : mInts) {
canvas.drawText(num + "", num * 100, 150, mPaint);
}
}
try {
Thread.sleep(600);
} catch (InterruptedException e) {
e.printStackTrace();
}
holder.unlockCanvasAndPost(canvas);
}
}
}).start();
}
}

方案二:在内容不交叉时,可以采用增量绘制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private void drawText(final SurfaceHolder holder) {
new Thread(new Runnable() {
@Override
public void run() {
// 先进行清屏操作
while (true) {
Rect dirtyRect = new Rect(0, 0, 1, 1);
Canvas canvas = holder.lockCanvas(dirtyRect);
Rect canvasRect = canvas.getClipBounds();
if (getWidth() == canvasRect.width() && getHeight() == canvasRect.height()) {
canvas.drawColor(Color.BLACK);
holder.unlockCanvasAndPost(canvas);
} else {
holder.unlockCanvasAndPost(canvas);
break;
}
}
// 画图
for (int i = 0; i < 10; i++) {
int itemWidth = 100;
int itemHeight = 100;
Rect rect = new Rect(i*itemWidth, 0, (i+1)*itemWidth-10, itemHeight);
Canvas canvas = holder.lockCanvas(rect);
if (canvas != null) {
canvas.drawColor(Color.GREEN);
canvas.drawText(i + "", i*itemWidth+10, itemHeight/2f, mPaint);
}
holder.unlockCanvasAndPost(canvas);
try {
Thread.sleep(600);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}

局部更新清屏代码,在每次开始运行程序时,在获取第二缓冲画布时,依然是全屏画布。但是同样的代码,从任务列表恢复程序时,又运行正常。百思不得其解!!!